Skip to content

perf: avoid allocating a mapping lambda on every labelValues() call - #2442

Open
david-mollitor-db wants to merge 2 commits into
prometheus:mainfrom
david-mollitor-db:labelvalues-fast-path
Open

david-mollitor-db wants to merge 2 commits into
prometheus:mainfrom
david-mollitor-db:labelvalues-fast-path

Conversation

@david-mollitor-db

Copy link
Copy Markdown
Contributor

What

StatefulMetric.labelValues(...) — the path every counter.labelValues(...).inc() /
histogram.labelValues(...).observe(...) goes through — called
data.computeIfAbsent(key, l -> newDataPoint()) on every invocation. The mapping function
captures this (via metadata/labelNames), so it is not a cached singleton: a new lambda
instance is allocated on every call, including the overwhelmingly common case where the data point
already exists (the lambda argument is constructed before computeIfAbsent runs, even though it is
only invoked on a miss).

This adds a data.get(key) fast path that returns the existing data point without constructing the
lambda:

List<String> key = Arrays.asList(labelValues);
T dataPoint = data.get(key);
if (dataPoint != null) {
  return dataPoint;
}
// miss: validate the raw array outside the CHM lock, then create at-most-once
for (int i = 0; i < labelValues.length; i++) {
  if (labelValues[i] == null) {
    throw new IllegalArgumentException(
        "null label value for metric " + metadata.getName() + " and label " + labelNames[i]);
  }
}
return data.computeIfAbsent(key, l -> newDataPoint());

Two secondary points folded in on the miss path:

  • the null-value validation now runs before computeIfAbsent, i.e. outside the
    ConcurrentHashMap bin lock, and operates on the raw String[] (no List.get indirection);
  • creation stays inside computeIfAbsent on purpose — newDataPoint() has a side effect (a native
    histogram may schedule a reset task via Scheduler.schedule), so at-most-once creation must be
    preserved; putIfAbsent with a pre-built value would leak the loser's scheduled task on a race.

Why

labelValues(...) is the hottest path in the library (every metric update). In a JMH benchmark of a
histogram-heavy workload, the get() fast path cut record-path allocation by ~18% — exactly the
16-byte captured lambda per observation.

Correctness

Behavior is unchanged (a null label value still throws on first use, since a null-containing key is
never inserted and so always reaches the miss branch). Verified by the core tests, including
StatefulMetricTest.

This pull request and its description were written by Isaac.

labelValues() went straight to data.computeIfAbsent(key, l -> ...). The mapping
function captures 'this', so a new lambda instance was allocated on every call -
including the common case where the data point already exists, since the lambda
argument is constructed before computeIfAbsent runs.

Add a data.get(key) fast path that returns the existing data point without
constructing the lambda. In a JMH benchmark of a histogram-heavy workload this
cut record-path allocation by ~18% (exactly the 16-byte captured lambda per
observation).

On the miss path, validate the label values on the raw array before
computeIfAbsent, so the null check runs outside the ConcurrentHashMap bin lock
and without List indirection. Creation stays inside computeIfAbsent: newDataPoint()
has side effects (a native histogram may schedule a reset task), so at-most-once
creation must be preserved.

Behavior is unchanged (verified by the core tests, including StatefulMetricTest).

Signed-off-by: David Mollitor <david.mollitor@databricks.com>
@zeitlinger

Copy link
Copy Markdown
Member

Pending the benchmark-only PR #2468
before making the release/merge decision on this optimization.

That PR adds repeated existing-label lookup/increment benchmarks with cached-data-point baselines,
single-threaded and contended variants, and B/op reporting. The existing counter benchmarks cache
their data points and therefore do not directly measure the claimed lookup allocation savings.

Once #2468 is merged, update this branch and retrigger the benchmark label so both base and head
contain the same lookup benchmark code. Then evaluate throughput and allocation for this change.
The benchmark PR itself initially has head-only results for the new methods and cannot establish
this optimization's benefit.

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Benchmark results

Benchmark run succeeded for 491ce2b8ce3f8c1009b6155786754b381908be6c.

Prometheus Java Client Benchmarks

Run Information

  • Date: 2026-09-15T17:42:58Z
  • Commit: 491ce2b
  • JDK: 25.0.3 (OpenJDK 64-Bit Server VM)
  • Benchmark config: 3 fork(s), 3 warmup, 5 measurement, 1/4 threads
  • Hardware: AMD EPYC 9V74 80-Core Processor, 4 cores, 16 GB RAM
  • OS: Linux 6.17.0-1022-azure

Comparison with base

  • Head: 491ce2b
  • Base: 59ca1f0
  • Metric direction: Throughput scores are higher-is-better; positive Head vs base deltas indicate faster performance.
  • Uncertainty: values include JMH 99.9% confidence intervals; verdicts use interval overlap and a practical-change threshold as a conservative heuristic, not as a statistical significance test.
  • Note: Base and head are compared on the same runner within each topic.
Benchmark PR (99.9% CI) Base (99.9% CI) Head vs base Regression verdict
CounterBenchmark.prometheusAdd 28.47K [28.28K, 28.66K] 27.87K [27.11K, 28.62K] +2.2% inconclusive (overlapping intervals)
CounterBenchmark.prometheusCachedLabelValuesInc 286.23M [284.41M, 288.05M] 295.12M [288.33M, 301.92M] -3.0% below 5% threshold
CounterBenchmark.prometheusCachedLabelValuesIncSingleThread 93.35M [93.31M, 93.39M] 93.32M [93.25M, 93.40M] +0.0% inconclusive (overlapping intervals)
CounterBenchmark.prometheusInc 31.52K [31.48K, 31.57K] 30.66K [29.39K, 31.93K] +2.8% inconclusive (overlapping intervals)
CounterBenchmark.prometheusLabelValuesInc 94.32M [93.71M, 94.92M] 67.88M [67.70M, 68.06M] +38.9% meaningful improvement
CounterBenchmark.prometheusLabelValuesIncSingleThread 58.32M [56.45M, 60.20M] 43.26M [43.11M, 43.42M] +34.8% meaningful improvement
CounterBenchmark.prometheusNoLabelsInc 30.41K [28.91K, 31.91K] 30.00K [28.81K, 31.19K] +1.4% inconclusive (overlapping intervals)
HistogramBenchmark.prometheusClassic 7.72K [5.03K, 10.42K] 5.83K [4.52K, 7.13K] +32.5% inconclusive (overlapping intervals)
HistogramBenchmark.prometheusClassicPerThread 13.24K [13.23K, 13.25K] 13.34K [13.18K, 13.49K] -0.7% inconclusive (overlapping intervals)
HistogramBenchmark.prometheusClassicSingleThread 5.34K [5.34K, 5.35K] 5.35K [5.34K, 5.35K] -0.1% inconclusive (overlapping intervals)
HistogramBenchmark.prometheusNative 2.74K [2.54K, 2.95K] 2.76K [2.51K, 3.00K] -0.4% inconclusive (overlapping intervals)
HistogramTextFormatBenchmark.openMetricsWriteToNull 35.06K [34.93K, 35.20K] 34.41K [33.89K, 34.92K] +1.9% below 5% threshold
HistogramTextFormatBenchmark.prometheusWriteToNull 35.45K [35.16K, 35.75K] 35.43K [35.26K, 35.59K] +0.1% inconclusive (overlapping intervals)
TextFormatUtilBenchmark.openMetricsWriteToByteArray 715.48K [712.27K, 718.68K] 716.71K [710.98K, 722.43K] -0.2% inconclusive (overlapping intervals)
TextFormatUtilBenchmark.openMetricsWriteToNull 729.59K [726.24K, 732.94K] 733.36K [727.44K, 739.28K] -0.5% inconclusive (overlapping intervals)
TextFormatUtilBenchmark.prometheusWriteToByteArray 764.20K [759.49K, 768.91K] 768.23K [764.45K, 772.02K] -0.5% inconclusive (overlapping intervals)
TextFormatUtilBenchmark.prometheusWriteToNull 784.72K [779.20K, 790.23K] 788.38K [780.16K, 796.61K] -0.5% inconclusive (overlapping intervals)

Results for PR head

CounterBenchmark

Benchmark Score Error Units
prometheusCachedLabelValuesInc 286.23M ± 1817.87K ops/s
prometheusLabelValuesInc 94.32M ± 605.29K ops/s
prometheusCachedLabelValuesIncSingleThread 93.35M ± 39.90K ops/s
prometheusLabelValuesIncSingleThread 58.32M ± 1877.31K ops/s
prometheusInc 31.52K ± 43.20 ops/s
prometheusNoLabelsInc 30.41K ± 1.50K ops/s
prometheusAdd 28.47K ± 188.00 ops/s

HistogramBenchmark

Benchmark Score Error Units
prometheusClassicPerThread 13.24K ± 10.53 ops/s
prometheusClassic 7.72K ± 2.69K ops/s
prometheusClassicSingleThread 5.34K ± 5.36 ops/s
prometheusNative 2.74K ± 208.59 ops/s

HistogramTextFormatBenchmark

Benchmark Score Error Units
prometheusWriteToNull 35.45K ± 294.34 ops/s
openMetricsWriteToNull 35.06K ± 136.35 ops/s

TextFormatUtilBenchmark

Benchmark Score Error Units
prometheusWriteToNull 784.72K ± 5.51K ops/s
prometheusWriteToByteArray 764.20K ± 4.71K ops/s
openMetricsWriteToNull 729.59K ± 3.35K ops/s
openMetricsWriteToByteArray 715.48K ± 3.20K ops/s

Allocation per operation

JMH GC profiler gc.alloc.rate.norm, in bytes per benchmark operation (lower is better).
Delta is PR minus base, shown only for matching benchmark configurations. Values are descriptive, not statistical regression verdicts; — means unavailable or not comparable. Each benchmark defines its own operation.

Benchmark PR B/op Base B/op Delta B/op
CounterBenchmark.prometheusAdd 0.130 0.132 -0.002
CounterBenchmark.prometheusCachedLabelValuesInc 0.000 0.000 +0.000
CounterBenchmark.prometheusCachedLabelValuesIncSingleThread 0.000 0.000 +0.000
CounterBenchmark.prometheusInc 0.117 0.121 -0.004
CounterBenchmark.prometheusLabelValuesInc 48.000 64.000 -16.000
CounterBenchmark.prometheusLabelValuesIncSingleThread 48.000 64.000 -16.000
CounterBenchmark.prometheusNoLabelsInc 0.121 0.123 -0.002
HistogramBenchmark.prometheusClassic 0.546 0.662 -0.116
HistogramBenchmark.prometheusClassicPerThread 0.607 0.607 -0.000
HistogramBenchmark.prometheusClassicSingleThread 0.545 0.544 +0.001
HistogramBenchmark.prometheusNative 417713.353 417713.352 +0.001
HistogramTextFormatBenchmark.openMetricsWriteToNull 43648.100 43648.102 -0.002
HistogramTextFormatBenchmark.prometheusWriteToNull 43648.099 43648.099 -0.000
TextFormatUtilBenchmark.openMetricsWriteToByteArray 18424.001 18424.001 +0.000
TextFormatUtilBenchmark.openMetricsWriteToNull 18424.001 18424.001 +0.000
TextFormatUtilBenchmark.prometheusWriteToByteArray 18448.001 18448.001 +0.000
TextFormatUtilBenchmark.prometheusWriteToNull 18448.001 18429.334 +18.667

Raw Results

Benchmark                                            Mode  Cnt          Score        Error  Units
CounterBenchmark.prometheusAdd                      thrpt   15      28471.093    ± 188.005  ops/s
CounterBenchmark.prometheusCachedLabelValuesInc     thrpt   15  286228155.515 ± 1817873.169  ops/s
CounterBenchmark.prometheusCachedLabelValuesIncSingleThread  thrpt   15   93349214.815  ± 39901.651  ops/s
CounterBenchmark.prometheusInc                      thrpt   15      31521.866     ± 43.195  ops/s
CounterBenchmark.prometheusLabelValuesInc           thrpt   15   94316545.797 ± 605287.782  ops/s
CounterBenchmark.prometheusLabelValuesIncSingleThread  thrpt   15   58323944.102 ± 1877305.843  ops/s
CounterBenchmark.prometheusNoLabelsInc              thrpt   15      30409.785   ± 1502.496  ops/s
HistogramBenchmark.prometheusClassic                thrpt   15       7722.146   ± 2694.846  ops/s
HistogramBenchmark.prometheusClassicPerThread       thrpt   15      13243.132     ± 10.533  ops/s
HistogramBenchmark.prometheusClassicSingleThread    thrpt   15       5342.711      ± 5.358  ops/s
HistogramBenchmark.prometheusNative                 thrpt   15       2743.896    ± 208.592  ops/s
HistogramTextFormatBenchmark.openMetricsWriteToNull  thrpt   15      35064.182    ± 136.346  ops/s
HistogramTextFormatBenchmark.prometheusWriteToNull  thrpt   15      35452.874    ± 294.340  ops/s
TextFormatUtilBenchmark.openMetricsWriteToByteArray  thrpt   15     715477.697   ± 3204.399  ops/s
TextFormatUtilBenchmark.openMetricsWriteToNull      thrpt   15     729589.618   ± 3345.467  ops/s
TextFormatUtilBenchmark.prometheusWriteToByteArray  thrpt   15     764200.868   ± 4711.714  ops/s
TextFormatUtilBenchmark.prometheusWriteToNull       thrpt   15     784715.503   ± 5514.396  ops/s

Notes

  • Score = the JMH primary metric; throughput is higher-is-better and latency is lower-is-better.
  • Error = 99.9% confidence interval
  • Regression verdict requires comparable benchmark metadata, non-overlapping JMH confidence intervals, and a change of at least 5%; otherwise it is marked "below the practical threshold" or "inconclusive". This is a conservative heuristic, not a statistical significance test.
  • Scores for different benchmark methods are not ranked against one another; they may measure different workloads.

Benchmark Descriptions

Benchmark Description
CounterBenchmark Counter updates and label-value lookup (selected methods only)
HistogramBenchmark Histogram observation performance (classic vs native/exponential)
TextFormatUtilBenchmark Metric exposition format writing speed

@zeitlinger

Copy link
Copy Markdown
Member

I ran a matched local JMH comparison using the identical #2468 lookup/cached harness on both candidate base and head, with #2471 applied to both. Configuration: JDK 25.0.3, 3 forks, 3x10s warmup, 5x10s measurement, GC profiler. Results (base -> head): lookup single-thread 62.85M -> 88.12M ops/s (+40.2%), 64 -> 48 B/op (-25%); lookup 4-thread 225.36M -> 295.40M ops/s (+31.1%), 64 -> 48 B/op (-25%). Cached controls: 520.25M -> 534.07M (+2.7%) single-thread and 1.504B -> 1.513B (+0.6%) 4-thread, both ~0 B/op. This supports #2442 as a worthwhile release optimization. Full raw JSON is available locally during review.

zeitlinger added a commit that referenced this pull request Sep 15, 2026
…2468)

## Summary

Benchmark-only follow-up for the release review; no production metric
changes.

- Restrict both base and head PR benchmark runs to client_java
counter/histogram methods.
OpenTelemetry, Codahale, and legacy simpleclient comparisons remain in
full/local/nightly runs.
  Keep all client_java exposition benchmarks, including OpenMetrics.
- Add repeated existing-label lookup + increment benchmarks and
cached-data-point baselines,
with one-thread and four-thread variants. One invocation is one metric
update.
- Report GC profiler allocation in B/op separately from throughput,
including descriptive
  base/head allocation deltas only when configurations match.
- Add selection/report regression tests and run benchmark tooling tests
in lint CI.
- Document operation units and the new benchmark workflow.

## Related PR

[Label lookup optimization
#2442](#2442) is pending
this
benchmark infrastructure. After this lands, update that branch and rerun
the benchmarks so base and
head contain identical lookup benchmark code. The new methods in this PR
itself have head-only
results and do not establish the optimization's benefit.

## Validation

- `mise run lint:fix` — passed; formatter changes retained.
- `mise run test` — passed.
- All three benchmark tooling test scripts — 24 tests passed.
- `mise run build -- -DskipITs=true` — passed.
- Plain `mise run build` compiled the benchmark module, but
Docker-backed integration tests ran
despite `-DskipTests` and failed because no Docker environment was
available.
- JMH listing with each workflow pattern confirmed client_java-only
selection and retained
  OpenMetrics/Prometheus exposition cases.
- All four new benchmarks completed with GC profiling: 1 fork, 2 x 1s
warmups, 3 x 1s measurements,
JDK 25.0.3, `-Xms128m -Xmx256m`. This was a smoke test on a shared
development host, not a
controlled base/head performance comparison. Repeated lookup reported
about 64 B/update;
  cached increments were near zero.
- Generated the Markdown report from real smoke-run JSON and checked
allocation output.
- `git diff --check` — passed.

---------

Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
Co-authored-by: Jay DeLuca <jaydeluca4@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants